Skip to content

fix(detail): a related list has one sorting semantics instead of two (#3106) - #3113

Merged
os-zhuang merged 3 commits into
mainfrom
claude/relatedlist-sort-consolidation
Jul 31, 2026
Merged

fix(detail): a related list has one sorting semantics instead of two (#3106)#3113
os-zhuang merged 3 commits into
mainfrom
claude/relatedlist-sort-consolidation

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Stacked on #3112 — base branch is claude/datatable-server-sort, since this uses the manualSorting API that PR adds. Review after it; GitHub will retarget to main when #3112 merges.

What was wrong

A related list carried two sorting semantics at once:

control scope default
its own sort-button row server $orderby over the whole child collection opt-in (sortable, default false)
the embedded data-table's headers the rows in hand — in windowed mode, one page always on (table's own default)

Turning sortable on put both in the same card, with nothing saying they meant different things. Leaving it off — the default — was worse: the page-local sort was then the only one a user could reach, and it looked exactly like the list being sorted.

How

The table's column headers now drive this list's sort in both modes, so there is one order behind them:

The button row survives only where there are no headers to click: a list (data-list) related list, or a caller-supplied schema whose contents we cannot assume. sortable's doc comment now says that is what it controls, rather than implying it governs sorting as a whole (which it never did for a table list — the headers were always live).

Relational columns keep #3096's rule, moved to the header: withheld while the sort is a server $orderby (the key would be the stored foreign-key id while the cell shows a name), live in client mode where the key is the label.

About the three modified test files

Worth a look, since they are pre-existing assertions I changed rather than added:

  • RelatedList.relationalSort.test.tsx (both cases) — encodes lookup 列排序按裸外键 id 排:服务端 $orderby 发平面 FK 字段,单元格却显示 $expand 后的标签 #3096's guarantee, asserted by clicking sort buttons. Same guarantee, now read off the column's sortable flag and driven through the table's onSortChange.
  • RelatedList.serverpagination.test.tsx — one case that clicked a sort button to prove the $orderby + page reset. Same assertion, triggered through the header.
  • RelatedList.systemcolumns.test.tsx — used the button row as a proxy for column order. It now reads the column order directly off the schema, which is what it was actually testing.

None of them lost coverage; each moved to the control that now carries the behavior.

Tests

9 new cases in RelatedList.headerSort.test.tsx, including the two-modes split and "the button row is gone for a table list but stays for a list one".

plugin-detail full suite: 294 passed (34 files). Lint: 0 errors.

Related

🤖 Generated with Claude Code

os-zhuang and others added 2 commits July 31, 2026 17:26
…t, not the page you can see (#3106)

Clicking a column header under server pagination sorted the CURRENT PAGE. The
user saw "sorted by this column" and got "these fifty rows are in order; page 2
starts over". The sort was real — its scope was not the one the screen implied
— and it had no way out of `data-table` at all: it lived in two `useState`s
with no callback, so the layer that issues the request could not see it even in
principle.

DataTable gains `manualSorting` + a controlled `sort` + `onSortChange`. In that
mode it sorts nothing, reports what a header click asks for, and renders `sort`
as the indicator — holding NO sort state of its own, because a private copy
beside a controlled prop is the shape the defect had. `manualSorting` is
independent of `manualPagination`: what matters is whether `data` is a window,
not who owns the pager.

ObjectGrid turns that into a `$orderby` in both of its server modes (its own
fetch and a parent-driven one) and returns to page 1, since a new ordering
makes the old page index a different set of rows. ListView lands it in
`currentSort` — the same state the toolbar's sort builder writes. One sort with
two controls onto it is what makes "does a header sort outrank the saved view's
sort?" a non-question rather than a precedence rule someone has to remember.

Three decisions worth naming:

- A header click REPLACES the order rather than appending, so the column under
  the cursor is the one the list is sorted by. Multi-key orders still come from
  the sort builder, and the headers render them numbered.
- It cannot ask for "no sort". The third click clears in client mode, which is
  meaningful there (rows return to the order they arrived in); across a
  server-paged collection there is no such order (objectstack#4363), so a header
  offering it would hand the user a worse lie than the one being fixed.
- Relational columns render no sort affordance under server sorting: a `lookup`
  column shows a related record's name while `$orderby` can only order by the
  stored id (objectstack#4256) — the same reason #3096 removed them from the
  toolbar's picker. Client-side sorting keys off the rendered label, so those
  headers stay live there.

The header context menu's Sort Ascending/Descending items now route through the
same write path; they wrote internal state directly, which under `manualSorting`
would have been a menu item that highlights, closes, and changes nothing.

Client-side tables are untouched: same three-state cycle, same local sort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…3106)

A related list carried two. Its own sort-button row (opt-in via `sortable`) went
out as a server `$orderby` over the whole child collection; the `data-table` it
embeds took `sortable`'s default of `true` and sorted the rows it was holding —
which, in windowed mode, is ONE PAGE.

Turning `sortable` on put both in the same card with nothing saying they meant
different things. Leaving it off — the default — was worse: the page-local sort
was then the only one the user could reach, and it looked exactly like the list
being sorted.

The embedded table's column headers now drive this list's sort in both modes:

- Windowed: the header sort becomes the server `$orderby` and resets to page
  one, the same path the buttons took.
- Client mode: this list keeps sorting in memory, where the key is the label
  resolved through its own id → name map (#3096) — a key the table cannot see,
  so its sort was the worse of the two even where both were possible.

The button row survives only where there are no headers to click: a `list`
(`data-list`) related list, or a caller-supplied `schema` whose contents we
cannot assume. `sortable`'s doc comment now says that is what it controls.

Relational columns keep #3096's rule, moved to the header: withheld while the
sort is a server `$orderby` (the key would be the stored foreign-key id while
the cell shows a name), live in client mode where the key is the label.

Three existing suites asserted through the sort buttons — two on #3096's own
guarantee, one using them as a proxy for column order. They now read the
column's `sortable` flag and drive the table's `onSortChange`, so the
guarantees they encode survive the control that carried them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Jul 31, 2026 9:36am

Request Review

@os-zhuang
os-zhuang deleted the branch main July 31, 2026 09:38
Base automatically changed from claude/datatable-server-sort to main July 31, 2026 09:38
@os-zhuang os-zhuang closed this Jul 31, 2026
@os-zhuang os-zhuang reopened this Jul 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-zwz2_FCE.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.26KB 2.99KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.12KB 3.41KB
auth (LoginForm.js) 17.86KB 5.29KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.43KB 2.09KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 474.90KB 104.10KB
core (index.js) 2.20KB 0.79KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.20KB 34.74KB
fields (index.js) 222.06KB 54.35KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.46KB 0.96KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 5.37KB 1.72KB
i18n (useObjectLabel.js) 25.17KB 5.80KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.44KB 10.66KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.90KB 12.35KB
plugin-charts (index.js) 60.52KB 17.11KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 111.59KB 28.74KB
plugin-designer (index.js) 210.51KB 42.50KB
plugin-detail (index.js) 222.38KB 54.45KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.38KB 26.93KB
plugin-gantt (index.js) 162.26KB 39.53KB
plugin-grid (index.js) 183.32KB 48.56KB
plugin-kanban (index.js) 47.82KB 13.18KB
plugin-list (index.js) 105.07KB 25.24KB
plugin-map (index.js) 16.80KB 5.24KB
plugin-markdown (index.js) 13.65KB 4.67KB
plugin-report (index.js) 40.32KB 10.53KB
plugin-timeline (index.js) 25.75KB 7.32KB
plugin-tree (index.js) 8.36KB 2.81KB
plugin-view (index.js) 83.54KB 20.39KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 3.47KB 1.54KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.45KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 1.08KB 0.64KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang merged commit bac266c into main Jul 31, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/relatedlist-sort-consolidation branch July 31, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant